#!/usr/bin/env python
"""Module to setup an RFC2136-capable DNS server"""
import os
import os.path
import shutil
import socket
import subprocess
import sys
import tempfile
import time
from typing import Optional

from pkg_resources import resource_filename

BIND_DOCKER_IMAGE = "internetsystemsconsortium/bind9:9.16"
BIND_BIND_ADDRESS = ("127.0.0.1", 45953)

# A TCP DNS message which is a query for '. CH A' transaction ID 0xcb37. This is used
# by _wait_until_ready to check that BIND is responding without depending on dnspython.
BIND_TEST_QUERY = bytearray.fromhex("0011cb37000000010000000000000000010003")


class DNSServer:
    """
    DNSServer configures and handles the lifetime of an RFC2136-capable server.
    DNServer provides access to the dns_xdist parameter, listing the address and port
    to use for each pytest node.

    At this time, DNSServer should only be used with a single node, but may be expanded in
    future to support parallelization (https://github.com/certbot/certbot/issues/8455).
    """

    def __init__(self, unused_nodes, show_output=False):
        """
        Create an DNSServer instance.
        :param list nodes: list of node names that will be setup by pytest xdist
        :param bool show_output: if True, print the output of the DNS server
        """

        self.bind_root = tempfile.mkdtemp()

        self.process: Optional[subprocess.Popen] = None

        self.dns_xdist = {"address": BIND_BIND_ADDRESS[0], "port": BIND_BIND_ADDRESS[1]}

        # Unfortunately the BIND9 image forces everything to stderr with -g and we can't
        # modify the verbosity.
        # pylint: disable=consider-using-with
        self._output = sys.stderr if show_output else open(os.devnull, "w")

    def start(self):
        """Start the DNS server"""
        try:
            self._configure_bind()
            self._start_bind()
        except:
            self.stop()
            raise

    def stop(self):
        """Stop the DNS server, and clean its resources"""
        if self.process:
            try:
                self.process.terminate()
                self.process.wait()
            except BaseException as e:
                print("BIND9 did not stop cleanly: {}".format(e), file=sys.stderr)

        shutil.rmtree(self.bind_root, ignore_errors=True)

        if self._output != sys.stderr:
            self._output.close()

    def _configure_bind(self):
        """Configure the BIND9 server based on the prebaked configuration"""
        bind_conf_src = resource_filename(
            "certbot_integration_tests", "assets/bind-config"
        )
        for directory in ("conf", "zones"):
            shutil.copytree(
                os.path.join(bind_conf_src, directory), os.path.join(self.bind_root, directory)
            )

    def _start_bind(self):
        """Launch the BIND9 server as a Docker container"""
        addr_str = "{}:{}".format(BIND_BIND_ADDRESS[0], BIND_BIND_ADDRESS[1])
        # pylint: disable=consider-using-with
        self.process = subprocess.Popen(
            [
                "docker",
                "run",
                "--rm",
                "-p",
                "{}:53/udp".format(addr_str),
                "-p",
                "{}:53/tcp".format(addr_str),
                "-v",
                "{}/conf:/etc/bind".format(self.bind_root),
                "-v",
                "{}/zones:/var/lib/bind".format(self.bind_root),
                BIND_DOCKER_IMAGE,
            ],
            stdout=self._output,
            stderr=self._output,
        )

        if self.process.poll():
            raise ValueError("BIND9 server stopped unexpectedly")

        try:
            self._wait_until_ready()
        except:
            # The container might be running even if we think it isn't
            self.stop()
            raise

    def _wait_until_ready(self, attempts: int = 30) -> None:
        """
        Polls the DNS server over TCP until it gets a response, or until
        it runs out of attempts and raises a ValueError.
        The DNS response message must match the txn_id of the DNS query message,
        but otherwise the contents are ignored.
        :param int attempts: The number of attempts to make.
        """
        if not self.process:
            raise ValueError("DNS server has not been started. Please run start() first.")

        for _ in range(attempts):
            if self.process.poll():
                raise ValueError("BIND9 server stopped unexpectedly")

            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(5.0)
            try:
                sock.connect(BIND_BIND_ADDRESS)
                sock.sendall(BIND_TEST_QUERY)
                buf = sock.recv(1024)
                # We should receive a DNS message with the same tx_id
                if buf and len(buf) > 4 and buf[2:4] == BIND_TEST_QUERY[2:4]:
                    return
                # If we got a response but it wasn't the one we wanted, wait a little
                time.sleep(1)
            except:  # pylint: disable=bare-except
                # If there was a network error, wait a little
                time.sleep(1)
            finally:
                sock.close()

        raise ValueError(
            "Gave up waiting for DNS server {} to respond".format(BIND_BIND_ADDRESS)
        )

    def __enter__(self):
        self.start()
        return self.dns_xdist

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.stop()
